--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit c8ea41c1e9f97555e998093449fa9dff251a6a1f
Parents : eca1486
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-06T22:28:59-05:00
feat(websocket): implement authentication for WebSocket mutators and update async utility functions
Changes
13 files changed, 414 insertions(+), 27 deletions(-)
Diff
diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py
index f351189a..9bc42b12 100644
--- a/meshchatx/meshchat.py
+++ b/meshchatx/meshchat.py
@@ -165,7 +165,10 @@ from meshchatx.src.backend.reticulum_config_guard import (
repair_unparseable_reticulum_config,
reticulum_config_has_required_sections,
)
-from meshchatx.src.backend.websocket_config_guard import sanitize_websocket_config_update
+from meshchatx.src.backend.websocket_config_guard import (
+ sanitize_websocket_config_update,
+ websocket_type_requires_auth,
+)
from meshchatx.src.backend.landlock_sandbox import (
apply_landlock_sandbox,
landlock_auto_enabled,
@@ -445,6 +448,7 @@ class ReticulumMeshChat:
self.current_context: IdentityContext | None = None
self._propagation_sync_metrics: dict[str, dict] = {}
+ AsyncUtils.ensure_background_loop()
self.setup_identity(identity)
self.web_audio_bridge = WebAudioBridge(None, None)
@@ -15616,6 +15620,23 @@ class ReticulumMeshChat:
def flush_all_archived_pages(self):
return self.nomadnet_manager.flush_all_archived_pages()
+ async def _websocket_session_authorized(self, client) -> bool:
+ if not self.auth_enabled:
+ return True
+ request = getattr(client, "request", None)
+ if request is None:
+ return False
+ try:
+ session = await get_session(request)
+ except Exception:
+ return False
+ identity_hash = self.identity.hash.hex() if self.identity else None
+ return bool(
+ session.get("authenticated", False)
+ and identity_hash
+ and session.get("identity_hash") == identity_hash
+ )
+
# handle data received from websocket client
async def on_websocket_data_received(self, client, data):
# get type from client data
@@ -15626,6 +15647,21 @@ class ReticulumMeshChat:
if not _type:
return
+ if websocket_type_requires_auth(_type):
+ if not await self._websocket_session_authorized(client):
+ logger.warning("Rejected unauthorized WebSocket mutator: %s", _type)
+ AsyncUtils.run_async(
+ client.send_str(
+ json.dumps(
+ {
+ "type": "error",
+ "message": "Authentication required",
+ },
+ ),
+ ),
+ )
+ return
+
# handle ping
if _type == "ping":
AsyncUtils.run_async(
diff --git a/meshchatx/src/backend/async_utils.py b/meshchatx/src/backend/async_utils.py
index 40205b1f..aea19280 100644
--- a/meshchatx/src/backend/async_utils.py
+++ b/meshchatx/src/backend/async_utils.py
@@ -1,10 +1,13 @@
# SPDX-License-Identifier: 0BSD AND MIT
import asyncio
+import logging
import threading
from collections.abc import Coroutine
from typing import Any, ClassVar
+_logger = logging.getLogger("meshchatx.async")
+
class AsyncUtils:
main_loop: asyncio.AbstractEventLoop | None = None
@@ -13,6 +16,35 @@ class AsyncUtils:
_futures_lock = threading.Lock()
_FUTURES_SWEEP_THRESHOLD = 32
_COROUTINES_MAX = 256
+ _background_loop: asyncio.AbstractEventLoop | None = None
+ _background_thread: threading.Thread | None = None
+ _background_ready = threading.Event()
+
+ @staticmethod
+ def ensure_background_loop() -> None:
+ """Start a daemon event loop for pre-web-server async work."""
+ if AsyncUtils.main_loop and AsyncUtils.main_loop.is_running():
+ return
+ if AsyncUtils._background_thread and AsyncUtils._background_thread.is_alive():
+ return
+
+ loop = asyncio.new_event_loop()
+ AsyncUtils._background_ready.clear()
+
+ def runner() -> None:
+ AsyncUtils.set_main_loop(loop)
+ AsyncUtils._background_ready.set()
+ loop.run_forever()
+
+ AsyncUtils._background_loop = loop
+ AsyncUtils._background_thread = threading.Thread(
+ target=runner,
+ name="meshchatx-async",
+ daemon=True,
+ )
+ AsyncUtils._background_thread.start()
+ if not AsyncUtils._background_ready.wait(timeout=5):
+ _logger.warning("Background asyncio loop did not become ready within 5s")
@staticmethod
def set_main_loop(loop: asyncio.AbstractEventLoop):
diff --git a/meshchatx/src/backend/identity_context.py b/meshchatx/src/backend/identity_context.py
index 36dd5234..c44a81a3 100644
--- a/meshchatx/src/backend/identity_context.py
+++ b/meshchatx/src/backend/identity_context.py
@@ -18,7 +18,12 @@ from meshchatx.src.backend.database import Database
from meshchatx.src.backend.docs_manager import DocsManager
from meshchatx.src.backend.repository_server_manager import RepositoryServerManager
from meshchatx.src.backend.forwarding_manager import ForwardingManager
-from meshchatx.src.backend.integrity_manager import IntegrityManager
+from meshchatx.src.backend.async_utils import AsyncUtils
+from meshchatx.src.backend.integrity_manager import (
+ CriticalIntegrityError,
+ IntegrityManager,
+ select_critical_integrity_issues,
+)
from meshchatx.src.backend.map_manager import MapManager
from meshchatx.src.backend.meshchat_utils import create_lxmf_router
from meshchatx.src.backend.message_handler import MessageHandler
@@ -153,6 +158,11 @@ class IdentityContext:
if not hasattr(self.app, "integrity_issues"):
self.app.integrity_issues = []
self.app.integrity_issues.extend(issues)
+ critical = select_critical_integrity_issues(issues)
+ if critical:
+ raise CriticalIntegrityError(
+ "Critical integrity failure: " + "; ".join(critical),
+ )
try:
self.database.initialize()
diff --git a/meshchatx/src/backend/integrity_manager.py b/meshchatx/src/backend/integrity_manager.py
index 7185c831..764b661d 100644
--- a/meshchatx/src/backend/integrity_manager.py
+++ b/meshchatx/src/backend/integrity_manager.py
@@ -11,9 +11,27 @@ from pathlib import Path
from typing import ClassVar
+class CriticalIntegrityError(RuntimeError):
+ """Raised when tampering is detected in identity or database files at startup."""
+
+
+def select_critical_integrity_issues(issues: list[str]) -> list[str]:
+ return [
+ issue
+ for issue in issues
+ if any(marker in issue for marker in IntegrityManager.CRITICAL_ISSUE_MARKERS)
+ ]
+
+
class IntegrityManager:
"""Manages the integrity of the database and identity files at rest."""
+ CRITICAL_ISSUE_MARKERS: ClassVar[tuple[str, ...]] = (
+ "Critical security component integrity compromised",
+ "Database structural issue",
+ "Identity mismatch",
+ )
+
# Filename globs frequently rewritten by RNS/LXMF or SQLite that should be
# ignored during integrity checks.
IGNORED_PATTERNS: ClassVar[list[str]] = [
diff --git a/meshchatx/src/backend/websocket_config_guard.py b/meshchatx/src/backend/websocket_config_guard.py
index 6d677129..232a5c45 100644
--- a/meshchatx/src/backend/websocket_config_guard.py
+++ b/meshchatx/src/backend/websocket_config_guard.py
@@ -1,6 +1,6 @@
# SPDX-License-Identifier: 0BSD
-"""WebSocket config update guards.
+"""WebSocket guards for config updates and authenticated mutators.
Settings that change the HTTP security boundary must go through CSRF-protected
HTTP endpoints, not the unauthenticated ``config.set`` WebSocket message.
@@ -19,6 +19,47 @@ WEBSOCKET_CONFIG_DENYLIST = frozenset(
},
)
+WEBSOCKET_PUBLIC_TYPES = frozenset(
+ {
+ "ping",
+ },
+)
+
+WEBSOCKET_READ_TYPES = frozenset(
+ {
+ "nomadnet.page.archives.get",
+ "nomadnet.page.archive.load",
+ "lxmf.forwarding.rules.get",
+ "keyboard_shortcuts.get",
+ },
+)
+
+WEBSOCKET_MUTATOR_TYPES = frozenset(
+ {
+ "config.set",
+ "keyboard_shortcuts.delete",
+ "keyboard_shortcuts.set",
+ "lxm.generate_paper_uri",
+ "lxm.ingest_uri",
+ "lxmf.forwarding.rule.add",
+ "lxmf.forwarding.rule.delete",
+ "lxmf.forwarding.rule.toggle",
+ "nomadnet.download.cancel",
+ "nomadnet.file.download",
+ "nomadnet.page.archive.add",
+ "nomadnet.page.archive.flush",
+ "nomadnet.page.download",
+ },
+)
+
+
+def websocket_type_requires_auth(msg_type: str) -> bool:
+ if msg_type in WEBSOCKET_PUBLIC_TYPES or msg_type in WEBSOCKET_READ_TYPES:
+ return False
+ if msg_type in WEBSOCKET_MUTATOR_TYPES:
+ return True
+ return False
+
def sanitize_websocket_config_update(config: object) -> dict:
"""Return a copy of *config* with security-sensitive keys removed."""
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index b5942d74..79d1477a 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -228,7 +228,7 @@
<!-- navigation -->
<div class="flex-1">
<ul class="py-3 pr-2 space-y-1">
- <li v-for="item in visibleNavItems" :key="item.id" v-if="isNavItemVisible(item)">
+ <li v-for="item in visibleNavItems" :key="item.id">
<SidebarLink :to="item.route" :is-collapsed="isSidebarCollapsed">
<template #icon>
<MaterialDesignIcon
@@ -700,7 +700,7 @@ export default {
return GlobalState.config?.rrc_enabled !== false;
},
visibleNavItems() {
- return listNavItems();
+ return listNavItems().filter((item) => this.isNavItemVisible(item));
},
isSyncingPropagationNode() {
return [
@@ -839,6 +839,9 @@ export default {
},
methods: {
isNavItemVisible(item) {
+ if (!item) {
+ return false;
+ }
if (item.visibleWhen === "rrcEnabled") {
return this.rrcEnabled;
}
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index 810e3f12..82fdf277 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -29,15 +29,6 @@
</div>
</div>
</div>
- <transition name="fade">
- <div
- v-if="copyToast"
- class="mt-3 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200 px-3 py-1 text-xs inline-flex items-center gap-2"
- >
- {{ copyToast }}
- <span class="w-2 h-2 rounded-full bg-emerald-500 animate-ping"></span>
- </div>
- </transition>
<div
class="grid grid-cols-1 sm:grid-cols-3 gap-2 sm:gap-3 mt-4 text-sm text-gray-600 dark:text-gray-300"
>
diff --git a/meshchatx/src/frontend/public/meshchatx-docs/meshchatx.md b/meshchatx/src/frontend/public/meshchatx-docs/meshchatx.md
index 95f8dfb9..0628cdf2 100644
--- a/meshchatx/src/frontend/public/meshchatx-docs/meshchatx.md
+++ b/meshchatx/src/frontend/public/meshchatx-docs/meshchatx.md
@@ -150,11 +150,19 @@ Authoring rules, security constraints for HTML/CSS, and API behaviour are docume
## Extensibility Points
+MeshChatX supports a capability-based plugin system with separate frontend and backend runtimes:
+
+- **Contribution registries** under `meshchatx/src/frontend/js/registries/` for sidebar navigation, tools, command palette actions, settings sections, and typed WebSocket events.
+- **Frontend plugins** run in dedicated Workers (`meshchatx/src/frontend/js/plugins/PluginHost.js`) with declarative UI slots rendered by `PluginSlotRenderer.vue`.
+- **Backend plugins** run in wasmtime with fuel metering and capability-gated host functions (`meshchatx/src/backend/plugin_manager.py`).
+- **Generic plugin API** under `/api/v1/plugins/*` for install, enable/disable, invoke, and asset serving.
+
The most practical extension points today are:
+- plugin manifests in `plugin.json` with `contributes` and `permissions` blocks,
- new API routes in backend routing sections,
- new manager modules under `meshchatx/src/backend`,
-- frontend page/component additions wired through existing router/state patterns,
+- frontend page/component additions wired through contribution registries,
- new config surface through CLI flags + environment variables,
- schema extension through the existing migration/versioning approach.
@@ -162,4 +170,5 @@ When adding features, prefer:
- identity-scoped state over global mutable state,
- explicit migration/version changes for DB schema updates,
-- endpoint-level tests plus focused manager unit tests.
+- endpoint-level tests plus focused manager unit tests,
+- plugin permissions that are declared in manifests and enforced by the host.
diff --git a/tests/backend/test_async_utils_critical.py b/tests/backend/test_async_utils_critical.py
index ffe1711e..ab89b655 100644
--- a/tests/backend/test_async_utils_critical.py
+++ b/tests/backend/test_async_utils_critical.py
@@ -6,6 +6,7 @@ from __future__ import annotations
import asyncio
import threading
+import time
import warnings
import pytest
@@ -18,12 +19,36 @@ def _reset_async_utils():
AsyncUtils.main_loop = None
AsyncUtils._pending_futures.clear()
AsyncUtils._pending_coroutines.clear()
+ AsyncUtils._background_loop = None
+ AsyncUtils._background_thread = None
+ AsyncUtils._background_ready.clear()
yield
AsyncUtils.main_loop = None
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
AsyncUtils._pending_futures.clear()
AsyncUtils._pending_coroutines.clear()
+ if AsyncUtils._background_loop and AsyncUtils._background_loop.is_running():
+ AsyncUtils._background_loop.call_soon_threadsafe(AsyncUtils._background_loop.stop)
+ AsyncUtils._background_loop = None
+ AsyncUtils._background_thread = None
+ AsyncUtils._background_ready.clear()
+
+
+def test_ensure_background_loop_schedules_coroutines_before_web_startup():
+ seen: list[bool] = []
+
+ async def record():
+ seen.append(True)
+
+ AsyncUtils.ensure_background_loop()
+ AsyncUtils.run_async(record())
+
+ deadline = time.time() + 2.0
+ while time.time() < deadline and not seen:
+ time.sleep(0.05)
+
+ assert seen == [True]
async def _noop():
diff --git a/tests/backend/test_integrity_startup_block.py b/tests/backend/test_integrity_startup_block.py
new file mode 100644
index 00000000..e8c6e946
--- /dev/null
+++ b/tests/backend/test_integrity_startup_block.py
@@ -0,0 +1,55 @@
+# SPDX-License-Identifier: 0BSD
+
+import tempfile
+from unittest.mock import MagicMock
+
+import pytest
+
+from meshchatx.src.backend.integrity_manager import (
+ CriticalIntegrityError,
+ select_critical_integrity_issues,
+)
+
+
+def test_select_critical_integrity_issues_filters_security_markers():
+ issues = [
+ "Last integrity snapshot: 2026-01-01",
+ "Critical security component integrity compromised: identity",
+ "File signature mismatch: stickers/foo.json",
+ ]
+ critical = select_critical_integrity_issues(issues)
+ assert critical == ["Critical security component integrity compromised: identity"]
+
+
+def test_identity_context_blocks_startup_on_critical_integrity_failure():
+ from meshchatx.src.backend.identity_context import IdentityContext
+
+ app = MagicMock()
+ app.emergency = False
+ app.auto_recover = False
+ app.integrity_issues = []
+ app.cleanup_rns_state_for_identity = MagicMock()
+
+ identity = MagicMock()
+ identity.hash = b"a" * 16
+ identity.get_private_key.return_value = b"private-key"
+
+ with tempfile.TemporaryDirectory() as tmp:
+ app.storage_dir = tmp
+ ctx = IdentityContext(identity, app)
+ ctx.integrity_manager.check_integrity = MagicMock(
+ return_value=(
+ False,
+ [
+ "Critical security component integrity compromised: identity",
+ ],
+ ),
+ )
+
+ with pytest.raises(CriticalIntegrityError):
+ ctx.setup()
+
+
+def test_non_critical_integrity_issues_do_not_block_selector():
+ issues = ["File signature mismatch: stickers/foo.json"]
+ assert select_critical_integrity_issues(issues) == []
diff --git a/tests/backend/test_lxmf_flood_protection.py b/tests/backend/test_lxmf_flood_protection.py
new file mode 100644
index 00000000..4440ec61
--- /dev/null
+++ b/tests/backend/test_lxmf_flood_protection.py
@@ -0,0 +1,69 @@
+# SPDX-License-Identifier: 0BSD
+
+import time
+from unittest.mock import MagicMock
+
+import pytest
+
+from meshchatx.meshchat import ReticulumMeshChat
+
+
+@pytest.fixture
+def flood_app(mock_app):
+ mock_app._lxmf_incoming_timestamps = []
+ mock_app._flood_protection_current_cost = None
+ mock_app._flood_protection_last_bump_time = 0
+ mock_app.current_context.config.lxmf_flood_protection_enabled.set(True)
+ mock_app.current_context.config.lxmf_flood_threshold_per_minute.set(5)
+ mock_app.current_context.config.lxmf_flood_max_stamp_cost.set(12)
+ mock_app.current_context.config.lxmf_inbound_stamp_cost.set(2)
+ mock_app.current_context.config.block_all_from_strangers.set(False)
+ mock_app.current_context.local_lxmf_destination = MagicMock()
+ mock_app.current_context.local_lxmf_destination.hash = b"\x01" * 16
+ mock_app.current_context.message_router = MagicMock()
+ mock_app.current_context.config.display_name.set("Peer")
+ return mock_app
+
+
+def test_lxmf_flood_protection_raises_stamp_cost_when_threshold_exceeded(flood_app):
+ now = time.time()
+ flood_app._lxmf_incoming_timestamps = [now - index for index in range(6)]
+
+ flood_app._check_lxmf_flood_protection()
+
+ assert flood_app.current_context.config.lxmf_inbound_stamp_cost.get() == 4
+ flood_app.current_context.message_router.set_inbound_stamp_cost.assert_called_once()
+
+
+def test_lxmf_flood_protection_steps_down_after_cooldown(flood_app):
+ now = time.time()
+ flood_app._lxmf_incoming_timestamps = [now - 120]
+ flood_app._flood_protection_current_cost = 2
+ flood_app._flood_protection_last_bump_time = now - 120
+ flood_app.current_context.config.lxmf_inbound_stamp_cost.set(6)
+ flood_app.current_context.config.lxmf_flood_cooldown_seconds.set(30)
+
+ flood_app._check_lxmf_flood_protection()
+
+ assert flood_app.current_context.config.lxmf_inbound_stamp_cost.get() == 5
+
+
+def test_lxmf_flood_protection_disabled_is_noop(flood_app):
+ flood_app.current_context.config.lxmf_flood_protection_enabled.set(False)
+ flood_app._lxmf_incoming_timestamps = [time.time()] * 20
+ flood_app.current_context.config.lxmf_inbound_stamp_cost.set(2)
+
+ flood_app._check_lxmf_flood_protection()
+
+ assert flood_app.current_context.config.lxmf_inbound_stamp_cost.get() == 2
+ flood_app.current_context.message_router.set_inbound_stamp_cost.assert_not_called()
+
+
+def test_lxmf_flood_protection_skips_when_block_strangers_enabled(flood_app):
+ flood_app.current_context.config.block_all_from_strangers.set(True)
+ flood_app._lxmf_incoming_timestamps = [time.time()] * 20
+ flood_app.current_context.config.lxmf_inbound_stamp_cost.set(2)
+
+ flood_app._check_lxmf_flood_protection()
+
+ flood_app.current_context.message_router.set_inbound_stamp_cost.assert_not_called()
diff --git a/tests/backend/test_websocket_config_guard.py b/tests/backend/test_websocket_config_guard.py
index ee9d299b..ff25031b 100644
--- a/tests/backend/test_websocket_config_guard.py
+++ b/tests/backend/test_websocket_config_guard.py
@@ -2,7 +2,13 @@
import pytest
-from meshchatx.src.backend.websocket_config_guard import sanitize_websocket_config_update
+from meshchatx.src.backend.websocket_config_guard import (
+ WEBSOCKET_MUTATOR_TYPES,
+ WEBSOCKET_PUBLIC_TYPES,
+ WEBSOCKET_READ_TYPES,
+ sanitize_websocket_config_update,
+ websocket_type_requires_auth,
+)
def test_sanitize_websocket_config_update_strips_auth_keys():
@@ -20,3 +26,12 @@ def test_sanitize_websocket_config_update_strips_auth_keys():
@pytest.mark.parametrize("payload", [None, [], "bad", 42])
def test_sanitize_websocket_config_update_rejects_non_dict(payload):
assert sanitize_websocket_config_update(payload) == {}
+
+
+def test_websocket_mutator_manifest_is_disjoint_from_public_and_read_types():
+ assert WEBSOCKET_MUTATOR_TYPES.isdisjoint(WEBSOCKET_PUBLIC_TYPES)
+ assert WEBSOCKET_MUTATOR_TYPES.isdisjoint(WEBSOCKET_READ_TYPES)
+
+
+def test_websocket_type_requires_auth_unknown_type_is_not_mutator():
+ assert websocket_type_requires_auth("not.a.real.type") is False
diff --git a/tests/backend/test_websocket_config_security.py b/tests/backend/test_websocket_config_security.py
index 8477d867..3cbba4af 100644
--- a/tests/backend/test_websocket_config_security.py
+++ b/tests/backend/test_websocket_config_security.py
@@ -1,7 +1,89 @@
# SPDX-License-Identifier: 0BSD
+import asyncio
+from unittest.mock import AsyncMock, MagicMock, patch
+
import pytest
+from meshchatx.src.backend.websocket_config_guard import (
+ WEBSOCKET_MUTATOR_TYPES,
+ WEBSOCKET_PUBLIC_TYPES,
+ WEBSOCKET_READ_TYPES,
+ websocket_type_requires_auth,
+)
+
+
+def _run_async_immediate(coro):
+ return asyncio.create_task(coro)
+
+
+def test_websocket_type_requires_auth_classifies_mutators():
+ for msg_type in WEBSOCKET_MUTATOR_TYPES:
+ assert websocket_type_requires_auth(msg_type) is True
+
+
+def test_websocket_type_requires_auth_allows_ping_and_reads():
+ for msg_type in WEBSOCKET_PUBLIC_TYPES | WEBSOCKET_READ_TYPES:
+ assert websocket_type_requires_auth(msg_type) is False
+
+
+@pytest.mark.asyncio
+async def test_websocket_mutator_rejected_without_session_when_auth_enabled(mock_app):
+ mock_app.config.auth_enabled.set(True)
+ mock_app.config.auth_password_hash.set("hash")
+ client = MagicMock()
+ client.request = MagicMock()
+ client.send_str = AsyncMock()
+
+ with (
+ patch.object(mock_app, "_websocket_session_authorized", return_value=False),
+ patch(
+ "meshchatx.meshchat.AsyncUtils.run_async",
+ side_effect=_run_async_immediate,
+ ),
+ ):
+ await mock_app.on_websocket_data_received(
+ client,
+ {"type": "keyboard_shortcuts.set", "shortcuts": []},
+ )
+ await asyncio.sleep(0)
+
+ client.send_str.assert_awaited_once()
+ payload = client.send_str.await_args.args[0]
+ assert '"Authentication required"' in payload
+
+
+@pytest.mark.asyncio
+async def test_websocket_read_allowed_without_session_when_auth_enabled(mock_app):
+ mock_app.config.auth_enabled.set(True)
+ client = MagicMock()
+ client.request = MagicMock()
+ client.send_str = AsyncMock()
+
+ with (
+ patch.object(mock_app, "_websocket_session_authorized", return_value=False),
+ patch.object(
+ mock_app,
+ "get_archived_page_versions",
+ return_value=[],
+ ),
+ patch(
+ "meshchatx.meshchat.AsyncUtils.run_async",
+ side_effect=_run_async_immediate,
+ ),
+ ):
+ await mock_app.on_websocket_data_received(
+ client,
+ {
+ "type": "nomadnet.page.archives.get",
+ "destination_hash": "aa" * 16,
+ "page_path": "index.mu",
+ },
+ )
+ await asyncio.sleep(0)
+
+ client.send_str.assert_awaited()
+
@pytest.mark.asyncio
async def test_websocket_config_set_ignores_auth_enabled(mock_app):
@@ -9,17 +91,18 @@ async def test_websocket_config_set_ignores_auth_enabled(mock_app):
mock_app.config.auth_password_hash.set("existing-hash")
client = object()
- await mock_app.on_websocket_data_received(
- client,
- {
- "type": "config.set",
- "config": {
- "display_name": "Updated Peer",
- "auth_enabled": False,
- "auth_password_hash": None,
+ with patch.object(mock_app, "_websocket_session_authorized", return_value=True):
+ await mock_app.on_websocket_data_received(
+ client,
+ {
+ "type": "config.set",
+ "config": {
+ "display_name": "Updated Peer",
+ "auth_enabled": False,
+ "auth_password_hash": None,
+ },
},
- },
- )
+ )
assert mock_app.config.auth_enabled.get() is True
assert mock_app.config.auth_password_hash.get() == "existing-hash"
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────